eletrotupi / tcc/ frontend/app/entry/[id].tsx88238be34dd62f1819ca47962a700d505097311f
13.6 KB | 448 lines Raw
1
import { useState, useEffect, use } from "react";
2
import {
3
  StyleSheet,
4
  View,
5
  ScrollView,
6
  Pressable,
7
  ActivityIndicator,
8
} from "react-native";
9
import { Text } from "@/components/ui/Text";
10
import { useLocalSearchParams, router, Stack } from "expo-router";
11
import { format } from "date-fns";
12
import { ptBR } from "date-fns/locale";
13

14
import { Card, SubtleInfoCard } from "@/components/ui/Cards";
15
import { NoLinkMessage } from "@/components/misc/NoLinkMessage";
16
import { Badge } from "@/components/ui/Badge";
17
import { Button } from "@/components/ui/Button";
18
import { Col, Between, Row } from "@/components/ui/LayoutHelpers";
19
import { Section, SectionHeader } from "@/components/ui/Sections";
20
import { Theme, Colors, Spacing } from "@/constants/theme";
21
import { getMood } from "@/constants/moods";
22
import {
23
  getMoodComponent,
24
  intensityLabel,
25
  intensityToValue,
26
} from "@/constants/mood-components";
27
import { useMoodEntry, useDeleteMoodEntry } from "@/hooks";
28
import { getTimeBadge, formatMoment } from "@/lib/utils/time";
29
import { MoodComponentCard } from "@/components/ui/MoodComponentCard";
30
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
31
import { getTriggerCategory } from "@/constants/trigger-categories";
32
import { getCareActionMeta } from "@/constants/care-action-categories";
33

34
const impactColor = (n: number) => {
35
  if (n <= 1) return '#86efac';
36
  if (n <= 2) return '#22c55e';
37
  if (n <= 3) return '#f59e0b';
38
  if (n <= 4) return '#f97316';
39
  return '#ef4444';
40
};
41

42
export default function MoodDetailScreen() {
43
  const { id } = useLocalSearchParams<{ id: string }>();
44
  const { data: entry, isLoading, isError } = useMoodEntry(id);
45
  const deleteMoodEntry = useDeleteMoodEntry();
46
  const [minutesLeft, setMinutesLeft] = useState(0);
47
  const [canDelete, setCanDelete] = useState(false);
48

49
  useEffect(() => {
50
    const updateTimer = () => {
51
      const moment = new Date(entry?.moment);
52
      const createdAt = new Date(entry?.moment);
53
      const msSinceCreation = Date.now() - createdAt.getTime();
54
      const deletionWindowMs = 5 * 60 * 1000;
55

56
      const newCanDelete = msSinceCreation < deletionWindowMs;
57
      const newMinutesLeft = Math.max(
58
        0,
59
        Math.ceil((deletionWindowMs - msSinceCreation) / 60000),
60
      );
61

62
      setCanDelete(newCanDelete);
63
      setMinutesLeft(newMinutesLeft);
64
    };
65

66
    updateTimer(); // Set initial values
67

68
    const interval = setInterval(updateTimer, 1000);
69

70
    return () => clearInterval(interval);
71
  }, [entry?.moment]);
72

73
  if (isLoading) {
74
    return (
75
      <View style={styles.centered}>
76
        <ActivityIndicator size="large" color={Theme.colors.tint} />
77
      </View>
78
    );
79
  }
80

81
  if (isError || !entry) {
82
    return (
83
      <View style={styles.centered}>
84
        <Text style={styles.errorText}>Registro não encontrado.</Text>
85
        <Pressable onPress={() => router.back()} style={styles.backButton}>
86
          <Text style={styles.backButtonText}>Voltar</Text>
87
        </Pressable>
88
      </View>
89
    );
90
  }
91

92
  const moment = new Date(entry.moment);
93
  const moodDef = getMood(entry.selectedMood.toLowerCase());
94
  const badge = getTimeBadge(moment);
95

96
  const handleDelete = async () => {
97
    await deleteMoodEntry.mutateAsync(String(entry.id));
98

99
    router.back();
100
  };
101

102
  const deleteButtonMsg = () => {
103
    const msg = "Excluir Registro";
104
    const timeLeftStr = `(Disp. por ${minutesLeft < 1 ? "<1" : minutesLeft}:00)`;
105
    const suffix = canDelete ? timeLeftStr : "(Expirado)";
106

107
    return `${msg} ${suffix}`;
108
  };
109

110
  const hasAnyLinks = (
111
    entry.triggerLinks && entry.triggerLinks.length > 0
112
  ) || (entry.careActions && entry.careActions.length > 0);
113

114
  return (
115
    <>
116
      <Stack.Screen options={{ title: "Ver detalhes" }} />
117

118
      <ScrollView
119
        style={styles.container}
120
        contentContainerStyle={styles.content}
121
        showsVerticalScrollIndicator={false}
122
      >
123
        {/* Header card */}
124
        <Card style={styles.headerCard}>
125
          <Between>
126
            <Row gap={12} style={{ alignItems: "center" }}>
127
              <Text style={styles.moodIcon}>{moodDef?.icon ?? "😐"}</Text>
128
              <Col gap={2}>
129
                <Text style={styles.moodLabel}>
130
                  {moodDef?.label ?? entry.selectedMood}
131
                </Text>
132
                <Text style={styles.moodTime}>{formatMoment(moment)}</Text>
133
              </Col>
134
            </Row>
135
            {/* If this is the latest mood entry, show the active badge */}
136
            <Badge
137
              label="Registro Ativo"
138
              backgroundColor={Theme.colors.activeBackground}
139
              color={Theme.colors.tint}
140
            />
141
          </Between>
142
        </Card>
143

144
        {/* Core levels */}
145
        <Section>
146
          <SectionHeader title="Níveis emocionais" variant="subtle" />
147
          {[
148
            { id: "energy", label: "Energia", value: entry.energyLevel },
149
            { id: "stress", label: "Estresse", value: entry.stressLevel },
150
            { id: "anxiety", label: "Ansiedade", value: entry.anxietyLevel },
151
          ].map((item, index, arr) => (
152
            <Card key={item.id} style={{ padding: Spacing.cardGap }}>
153
              <View key={item.label}>
154
                <View style={styles.componentRow}>
155
                  <Text style={styles.componentLabel}>{item.label}</Text>
156
                  <Text style={styles.componentIntensityGreen}>
157
                    {item.value}/10
158
                  </Text>
159
                </View>
160

161
                <View style={styles.progressTrack}>
162
                  <View
163
                    style={[
164
                      styles.progressFill,
165
                      { width: `${(item.value / 10) * 100}%` },
166
                    ]}
167
                  />
168
                </View>
169
              </View>
170
            </Card>
171
          ))}
172
        </Section>
173

174
        <Section>
175
          <SectionHeader title="Além disso, sinto..." variant="subtle" />
176
          <Col gap={0}>
177
            {entry.moodComponents.map((comp, index) => {
178
              const def = getMoodComponent(comp.component.toLowerCase());
179
              const numericIntensity =
180
                comp.intensity === "LIGHT"
181
                  ? 2
182
                  : comp.intensity === "MODERATE"
183
                    ? 5
184
                    : 8;
185

186
              return (
187
                <MoodComponentCard
188
                  key={comp.id}
189
                  id={comp.component.toLowerCase()}
190
                  intensity={comp.intensity}
191
                />
192
              );
193
            })}
194
          </Col>
195
        </Section>
196

197
        {/* Linked triggers */}
198
        <Section>
199
          <SectionHeader title="Vínculo" variant="subtle" />
200
          {!!hasAnyLinks ? (
201
            <Col gap={8}>
202
              {entry.triggerLinks.map((link) => {
203
                const cat = getTriggerCategory(link.trigger.category);
204
                return (
205
                  <Pressable
206
                    key={link.id}
207
                    onPress={() => router.push(`/triggers/${link.trigger.id}`)}
208
                    style={({ pressed }) => [pressed && { opacity: 0.7 }]}
209
                  >
210
                    <Card style={styles.linkCard}>
211
                      <Row style={{ alignItems: "center", gap: 12 }}>
212
                        <View style={[styles.linkIconCircle, { backgroundColor: cat.iconBackground }]}>
213
                          <MaterialIcons name={cat.icon} size={22} color={cat.color} />
214
                        </View>
215
                        <Col gap={3} style={{ flex: 1 }}>
216
                          <Row style={{ alignItems: "center", gap: 6 }}>
217
                            <Text style={styles.linkLabel}>{cat.label}</Text>
218
                            <Badge label="Gatilho" variant="orange" style={undefined} />
219
                          </Row>
220
                          <Text style={styles.linkTime}>
221
                            {formatMoment(new Date(link.trigger.moment))}
222
                          </Text>
223
                        </Col>
224
                        <Ionicons name="chevron-forward" size={16} color={Colors.light.disabled} />
225
                      </Row>
226
                      <View style={styles.impactBar}>
227
                        <View
228
                          style={[
229
                            styles.impactFill,
230
                            {
231
                              width: `${(link.perceivedImpact / 5) * 100}%` as any,
232
                              backgroundColor: impactColor(link.perceivedImpact),
233
                            },
234
                          ]}
235
                        />
236
                      </View>
237
                    </Card>
238
                  </Pressable>
239
                );
240
              })}
241

242
              {entry.careActions.map((ca) => {
243
                const meta = getCareActionMeta(ca);
244

245
                return (
246
                  <Pressable
247
                    key={ca.id}
248
                    onPress={() => (router.push as any)(`/care-actions/${ca.id}`)}
249
                    style={({ pressed }) => [pressed && { opacity: 0.7 }]}
250
                  >
251
                    <Card style={styles.linkCard}>
252
                      <Row style={{ alignItems: 'center', gap: 12 }}>
253
                        <View style={[styles.linkIconCircle, { backgroundColor: meta.iconBackground }]}>
254
                          <MaterialIcons name={meta.icon as any} size={22} color={meta.color} />
255
                        </View>
256
                        <Col gap={3} style={{ flex: 1 }}>
257
                          <Row style={{ alignItems: 'center', gap: 6 }}>
258
                            <Text style={styles.linkLabel}>{meta.label}</Text>
259
                            <Badge label="Consulta" variant="info" style={undefined} />
260
                          </Row>
261
                          <Text style={styles.linkTime}>
262
                            {formatMoment(new Date(ca.moment))}
263
                          </Text>
264
                        </Col>
265

266
                        <Ionicons name="chevron-forward" size={16} color={Colors.light.disabled} />
267
                      </Row>
268
                    </Card>
269
                  </Pressable>
270
                );
271
              })}
272
            </Col>
273
          ) : (
274
            <NoLinkMessage />
275
          )}
276
        </Section>
277

278
        {/* Annotation / notes */}
279
        {entry.annotation && (
280
          <Section>
281
            <SectionHeader title="Anotações" variant="subtle" />
282
            <Card style={{ padding: Spacing.cardGap }}>
283
              <Text style={styles.annotation}>
284
                "{entry.annotation?.trim()}"
285
              </Text>
286
            </Card>
287
          </Section>
288
        )}
289

290
        {/* Delete */}
291
        <View style={styles.section}>
292
          <Text style={styles.sectionLabel}>GESTÃO DO REGISTRO</Text>
293
          <Button
294
            variant="danger"
295
            isLoading={deleteMoodEntry.isPending}
296
            disabled={!canDelete || deleteMoodEntry.isPending}
297
            onPress={handleDelete}
298
            title={deleteButtonMsg()}
299
          />
300

301
          <SubtleInfoCard
302
            style={styles.deleteInfo}
303
            text={`
304
              A exclusão de registros é permitida apenas nos primeiros 5 minutos
305
              após o envio para garantir a integridade do histórico emocional.
306
              `}
307
          />
308
        </View>
309
      </ScrollView>
310
    </>
311
  );
312
}
313

314
const styles = StyleSheet.create({
315
  container: {
316
    flex: 1,
317
    backgroundColor: Theme.colors.background,
318
  },
319
  content: {
320
    padding: Spacing.containerPadding,
321
    gap: Spacing.sectionGap,
322
    paddingBottom: 48,
323
  },
324
  centered: {
325
    flex: 1,
326
    justifyContent: "center",
327
    alignItems: "center",
328
    gap: 12,
329
  },
330
  errorText: {
331
    fontSize: 15,
332
    color: Colors.light.textSecondary,
333
  },
334
  backButton: {
335
    paddingHorizontal: 20,
336
    paddingVertical: 10,
337
    backgroundColor: Theme.colors.tint,
338
    borderRadius: Theme.borderRadius.md,
339
  },
340
  backButtonText: {
341
    fontWeight: "700",
342
    color: Theme.colors.text,
343
  },
344

345
  // Header
346
  headerCard: {
347
    padding: 16,
348
  },
349
  moodIcon: {
350
    fontSize: 40,
351
  },
352
  moodLabel: {
353
    fontSize: 17,
354
    fontWeight: "700",
355
    color: Colors.light.text,
356
  },
357
  moodTime: {
358
    fontSize: 13,
359
    color: Colors.light.textSecondary,
360
  },
361

362
  // Sections
363
  section: {
364
    gap: 8,
365
  },
366
  sectionLabel: {
367
    fontSize: 11,
368
    fontWeight: "600",
369
    letterSpacing: 0.8,
370
    color: Colors.light.textSecondary,
371
    textTransform: "uppercase",
372
  },
373

374
  // Components
375
  componentRow: {
376
    flexDirection: "row",
377
    justifyContent: "space-between",
378
    alignItems: "center",
379
    paddingVertical: 10,
380
  },
381
  componentLabel: {
382
    fontSize: 15,
383
    fontWeight: "700",
384
    color: Colors.light.text,
385
  },
386
  componentIntensity: {
387
    fontSize: 13,
388
    color: Colors.light.textSecondary,
389
  },
390
  componentIntensityGreen: {
391
    fontSize: 13,
392
    fontWeight: "700",
393
    color: Theme.colors.tint,
394
  },
395
  progressTrack: {
396
    height: 6,
397
    backgroundColor: Theme.colors.light.divider,
398
    borderRadius: Theme.borderRadius.full,
399
    marginBottom: 4,
400
    overflow: "hidden",
401
  },
402
  progressFill: {
403
    height: "100%",
404
    backgroundColor: Theme.colors.light.tint,
405
    borderRadius: Theme.borderRadius.full,
406
  },
407
  // Annotation
408
  annotation: {
409
    fontSize: 14,
410
    lineHeight: 22,
411
    color: Colors.light.textSecondary,
412
    fontStyle: "italic",
413
    padding: 4,
414
  },
415
  // Linked trigger cards
416
  linkCard: {
417
    padding: Spacing.cardGap,
418
    overflow: "hidden",
419
    gap: 10,
420
  },
421
  linkIconCircle: {
422
    width: 44,
423
    height: 44,
424
    borderRadius: 22,
425
    alignItems: "center",
426
    justifyContent: "center",
427
  },
428
  linkTime: {
429
    fontSize: 12,
430
    color: Colors.light.textSecondary,
431
  },
432
  linkLabel: {
433
    fontSize: 15,
434
    fontWeight: "700",
435
    color: Colors.light.text,
436
  },
437
  impactBar: {
438
    height: 3,
439
    backgroundColor: Colors.light.divider,
440
    borderRadius: 99,
441
    overflow: "hidden",
442
  },
443
  impactFill: {
444
    height: "100%",
445
    borderRadius: 99,
446
  },
447

448
});